# GridMenu - GirdMenu contains a grid of textures and allows user to select one.
# Copyright (C) 2007 Lauri Taimila
# 
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
# 
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.

__licence__ = "GPLv2"
__copyright__ = "2007, Lauri Taimila"
__author__ = "Lauri Taimila <lauri@taimila.com>"

import clutter

class GridMenu(clutter.Group):
    """
    GridMenu widget
    
    This is a flexible menu widget that supports horizontal, vertical,
    scrollable, multirow menus. Real menus should inherit this class
    and override few methods.
    """
    #TODO:
    # - Scroll content over (first item comes after the last one)
    # - Fix animated cursor/content move (do we even want this?)
    # - Add support for PageUp/PageDown and Home/End
    # - Add search functionality
    # - Optimize performance: Hide/unrelize items that are not currently on screen
    
    # Orientation constants
    HORIZONTAL = 0
    VERTICAL = 1
    
    # Direction constants
    UP = 2
    DOWN = 3
    LEFT = 4
    RIGHT = 5
    
    # Cursor placement (Is cursor drawn above or below of all menuitems)
    BELOW = 6
    ABOVE = 7
    
    def __init__(self, orientation = 1): # Default orientation is VERTICAL
        """
        Initialize GridMenu widget
        @param orientation: Orientation of the menu. Use class constatants.
        """
        clutter.Group.__init__(self)
        
        self.itemgroup = clutter.Group()    # Group for menuitems
        self.items = []                     # List of Menuitems
        self.orientation = orientation      # Menu orientation
        self.row_count = 1                  # Items per row
        self.visible_cols = 8               # Number of visible columns
        self.cell_height = 100              # Menuitem height
        self.cell_width = 100               # Menuitem width
        self.cell_spacing = 0               # Space between items
        self.logical_position = 0           # Logical position (in item list)
        self.cursor_position = (0,0)        # Cursor position (on screen)
        self.content_position = (0,0)       # Content position
        self.animate = False                # Should we animate this widget
        self.cursor = None                  # Cursor (clutter.Actor)
        self.cursor_placement = 7           # Cursor placement (Default: Above)
        self.active = True                  # Is this menu active
        self.content_timeline = None        # Content scroll timeline
        self.cursor_timeline = None         # Cursor move timeline
        self.optimize_memory = False        # Should we optimize memory usage
        
        self.itemgroup.show()
        self.add(self.itemgroup)
        
        # Default red transparent cursor
        c= clutter.Rectangle()
        c.set_color((255,0,0,128))
        self.set_cursor(c)

    def optimize_memory_usage(self, boolean):
        """
        This method can be used to change widget's memory consuption. When
        memory usage is optimized, only visible menuitems are kept in memory.
        This saves memory, but the menu might be slower when scrolled.
        @param boolean: True to set optimize on, False to disable optimization
        """
        #FIXME: When changed relize or unrealize needed menuitems.
        self.optimize_memory = boolean;

    def set_cursor(self, cursor):
        """
        Set cursor actor. This is usually clutter.Texture or clutter.Rectangle.
        @param cursor: clutter.Actor - Cursor graphics
        """
        # Remove old if exists
        if self.cursor is not None:
            self.remove(self.cursor)
        
        # Set new cursor
        self.cursor = cursor
        
        # If widget is currently active display cursor
        if self.active:
            self.cursor.show()
            
        self.add(self.cursor)
        self.cursor.set_size(self.cell_width, self.cell_height)
        self._scale_cursor()
        
        # Set cursor placement
        if self.cursor_placement == self.__class__.BELOW:
            self.cursor.lower_bottom()
        elif self.cursor_placement == self.__class__.ABOVE:
            self.cursor.raise_top()
    
    def set_cursor_placement(self, placement):
        """
        Set cursor placement. Is cursor drawn below or above of all menuitems.
        @param placement: GridMenu.BELOW or GridMenu.ABOVE
        """
        self.cursor_placement = placement
        if self.cursor_placement == self.__class__.BELOW:
            self.cursor.lower_bottom()
        elif self.cursor_placement == self.__class__.ABOVE:
            self.cursor.raise_top()
    
    def set_active(self, boolean):
        """
        Set menu widget to active / disabled. Please see API references of
        the self._animate_items_on_state_change() method. It handels all actor
        changes when menu widget state changes.
        """
        self.active = boolean
        self._animate_items_on_state_change()
    
    def is_active(self):
        """
        Is this menu active?
        @return: True if menu is active, otherwise False
        """
        return self.active
    
    def set_animate(self, boolean):
        """
        Enable/disable widget animation.
        @param boolean: True if widget should be animated, otherwise False
        """
        self.animate = boolean
    
    def add_actor(self, item):
        """
        Add menuitem to this menu.
        @param item: clutter.Actor object
        """
        #item.set_size(self.cell_width, self.cell_height)
        self._scale_menuitem(item)   # Scale item
        self.items.append(item)      # Add to logical list
        self.itemgroup.add(item)     # Add to menuitem group (visible list)
        self._update_menuitem_position(item, len(self.items) - 1)
        item.show()
        
        # Update cursor
        if self.cursor_placement == self.__class__.BELOW:
            self.cursor.lower_bottom()
        elif self.cursor_placement == self.__class__.ABOVE:
            self.cursor.raise_top()
        
        # If this is a first item in the menu.
        if len(self.items) == 1:
            self._update_selected_item(0)
            self.set_visible_column_count(self.visible_cols)
        
    def remove_actor(self, item):
        """
        Remove menuitem from this menu.
        @param item: clutter.Actor object
        """
        try:
            self.items.remove(item)        # Remove from logical list
            self.itemgroup.remove(item)    # Remove from visible list group
            for i in range(len(self.items)):
                self._update_menuitem_position(self.items[i], i)
        except:
            raise Exception("Couldn't remove menuitem. Item doesn't exists.")
    
    def remove_all(self):
        """
        Remove all menuitems from this menu.
        """
        self.items = []
        self.itemgroup.remove_all()
    
    def get_n_children(self):
        """
        Override clutter.Group method. Returns number of menuitems.
        """
        #FIXME: Is this safe?
        return len(self.items)
    
    def get_nth_child(self, index):
        """
        Override clutter.Group method. Returns one menuitem.
        """
        #FIXME: Is this safe?
        return self.items[index]
    
    def get_number_of_items(self):
        """
        Get number of menuitems.
        @return: Integer
        """
        return len(self.items)
    
    def get_current_postition(self):
        """
        Get logical position of the menu cursor.
        """
        return self.logical_position
    
    def get_current_menuitem(self):
        """
        Get current menuitem. (Menuitem under menu cursor)
        @return: clutter.Actor
        """
        return self.items[self.logical_position]
    
    def get_cursor_position(self):
        """
        Get cursor position. This is a (x,y) coordinate of the cursor. Cursor
        position is always in displayble area.
        @return: (x,y) tuple
        """
        return self.cursor_position
    
    def set_orientation(self, orientation):
        """
        Set widget orientation. This defines which way widget is scrolled when
        all items don't fit into the widget area at the same time.
        @param orientation: GridMenu.HORIZONTAL or GridMenu.VERTICAL
        """
        if orientation < 0 or orientation > 1:
            raise Exception("Invalid orientation")
        self.orientation = orientation
        for i in range(len(self.items)):
            self._update_menuitem_position(self.items[i], i)
    
    def get_orientation(self):
        """
        Get menu orientation.
        @return: GridMenu.VERTICAL or GridMenu.HORIZONTAL (Integer constant)
        """
        return self.orientation
    
    def set_row_count(self, count):
        """
        Set how many items there should be on one row. Notice that row's
        direction depends on widget's orientation. For example, 'normal menu'
        would be VERTICAL with a row count 1.
        @param count: Number of items per row
        """
        if count < 1:
            raise Exception("Row count must be a positive value.")
        self.row_count = count
        for i in range(len(self.items)):
            self._update_menuitem_position(self.items[i], i)
        
    def get_row_count(self):
        """
        Get row count.
        @return: Integer
        """
        return self.row_count
    
    def set_visible_column_count(self, count):
        """
        Set how many columns are allowed to be visible at the same time. Column
        direction depends on widget orientation. For example, if menu is
        VERTICAL, row_count is 1 and visible columns is 8, then there are at
        most 8 visible items which are scrolled vertically.
        """
        if count < 1:
            raise Exception("Only positive values allowed!")
        self.visible_cols = count

        if self.orientation == self.__class__.VERTICAL:
            w = (self.cell_width + self.cell_spacing) * self.row_count
            h = count * (self.cell_height + self.cell_spacing)
        else:
            w = count * (self.cell_width + self.cell_spacing)
            h = (self.cell_height + self.cell_spacing) * self.row_count
            
        self.remove_clip()
        #print "clip area: (width: " + str(w) + " and height: " + str(h)
        self.set_clip(0,0,w,h)
        
    def get_visible_column_count(self):
        """
        Get the number of visible columns.
        @return: Integer
        """
        return self.visible_cols
    
    def set_item_spacing(self, space):
        """
        Set how many pixels should be left between menu items.
        @param space: Item spacing in pixels (0 or higher)
        """
        if space < 0:
            raise Exception("Spacing must be a positive integer.")
        self.cell_spacing = space
        for i in range(len(self.items)):
            self._update_menuitem_position(self.items[i], i)
        
    def set_item_height(self, height):
        """
        Set the height of the one cell in the grid.
        @param height: Height in pixels (positive value)
        """
        
        if height < 1:
            raise Exception("Height must be a positive value")
        
        self.cell_height = height
        
        # Update cursor
        self._scale_cursor()
        
        # Update menuitems
        for item in self.items:
            self._scale_menuitem(item)
        for i in range(len(self.items)):
            self._update_menuitem_position(self.items[i], i)
        
    def set_item_width(self, width):
        """
        Set the width of the one cell in the grid.
        @param height: Width in pixels (positive value)
        """
        if width < 1:
            raise Exception("Width must be a positive value")
        
        self.cell_width = width
        
        # Update cursor
        self._scale_cursor()
        
        # Update menuitems
        for item in self.items:
            self._scale_menuitem(item)
        for i in range(len(self.items)):
            self._update_menuitem_position(self.items[i], i)
        
    def set_item_size(self, width, height):
        """
        Set the width of the one cell in the grid.
        @param height: Width in pixels (positive value)
        """
        if width < 1 or height < 1 :
            raise Exception("Width and height must be positive values!")
        
        self.cell_width = width
        self.cell_height = height
        
        # Update clipping
        #self.set_visible_column_count(self.visible_cols)
        
        # Update cursor
        self._scale_cursor()
        
        # Update menuitems
        for item in self.items:
            self._scale_menuitem(item)
        for i in range(len(self.items)):
            self._update_menuitem_position(self.items[i], i)
    
    def move(self, direction):
        """
        Move to given direction. Cursor is moved or grid content is scrolled if 
        needed.
        @param direction: GridMenu class variables (UP, DOWN, LEFT, RIGHT)
        """
        if not self._is_move_legal(direction):
            return
            
        # Current position of the content and cursor
        cur_x, cur_y = self.cursor_position
        con_x, con_y = self.content_position
        
        # Define limit value for cursor and content move
        max_row = len(self.items) / self.row_count
        if (len(self.items) % self.row_count) != 0:
            max_row = max_row + 1

        content_scroll_max = max_row - self.visible_cols
        
        if self.visible_cols > max_row:
            # There are less rows than allowed to be visible at the same time
            cursor_scroll_max = max_row - 1
        else:
            cursor_scroll_max = self.visible_cols - 1
            
        # Menu has a vertical orientation
        if self.orientation == self.__class__.VERTICAL:
            if direction == self.__class__.UP:
                if cur_y == 0:
                    if con_y > 0:
                        self._move_content(1, self.__class__.DOWN)
                        self._update_memory_state()
                else:
                    self._move_cursor(self.__class__.UP) 
            elif direction == self.__class__.DOWN:
                if cur_y < cursor_scroll_max:
                    self._move_cursor(self.__class__.DOWN)
                else:
                    if con_y < content_scroll_max:
                        self._move_content(1, self.__class__.UP)
                        self._update_memory_state()
            elif direction == self.__class__.LEFT:
                if cur_x > 0:
                    self._move_cursor(self.__class__.LEFT)
            elif direction == self.__class__.RIGHT:
                if cur_x < self.row_count - 1:
                    self._move_cursor(self.__class__.RIGHT)
        
        # Menu has a horizontal orientation
        else:
            if direction == self.__class__.UP:
                if cur_y > 0:
                    self._move_cursor(self.__class__.UP)     
            elif direction == self.__class__.DOWN:
                if cur_y < self.row_count - 1:
                    self._move_cursor(self.__class__.DOWN)        
            elif direction == self.__class__.LEFT:
                if cur_x == 0:
                    if con_x > 0:
                        self._move_content(1, self.__class__.RIGHT)
                        self._update_memory_state()
                else:
                    self._move_cursor(self.__class__.LEFT)
            elif direction == self.__class__.RIGHT:
                if cur_x < cursor_scroll_max:
                    self._move_cursor(self.__class__.RIGHT)
                else:
                    if con_x < content_scroll_max:
                        self._move_content(1, self.__class__.LEFT)
                        self._update_memory_state()
        
        old_position = self.logical_position
        
        # Calculate logical position
        if self.orientation == self.__class__.VERTICAL:
            tmp = (self.cursor_position[1] + self.content_position[1]) * self.row_count
            self.logical_position = tmp + self.cursor_position[0]
        else:
            tmp = (self.cursor_position[0] + self.content_position[0]) * self.row_count
            self.logical_position = tmp + self.cursor_position[1]
            
        self._update_selected_item(self.logical_position, old_position)

    def _is_move_legal(self, direction):
        """
        Check if move is legal. This prevents cursor from leaving menuitems.
        This could happend when grid's last row is not "full".
        @param direction: GridMenu class variables (UP, DOWN, LEFT, RIGHT)
        @return: True if move can be executed, otherwise False
        """
        #TODO: This method is not needed if scrolling menu is implemented
        
        # If last row is "full" we can't go wrong
        if len(self.items) % self.row_count == 0:
            return True
        
        last_full_row = len(self.items) / self.row_count -1 # -1 because rows start from 0
        items_on_last_row = len(self.items) % self.row_count
        
        # Checking for vertical menu
        if self.orientation == self.__class__.VERTICAL:
            current_row = self.cursor_position[1] + self.content_position[1]
            if current_row == last_full_row and direction == self.__class__.DOWN:
                if self.cursor_position[0] + 1 > items_on_last_row:
                    return False
            if current_row == last_full_row + 1 and direction == self.__class__.RIGHT:
                if self.logical_position == len(self.items) - 1:
                    return False
        # Checking for horizontal menu
        else:
            current_row = self.cursor_position[0] + self.content_position[0]
            if current_row == last_full_row and direction == self.__class__.RIGHT:
                if self.cursor_position[1] + 1 > items_on_last_row:
                    return False
            if current_row == last_full_row + 1 and direction == self.__class__.DOWN:
                if self.logical_position == len(self.items) - 1:
                    return False
        return True
        
    def _move_cursor(self, direction):
        """
        Move cursor on grid.
        """
        # Change logical position 
        x,y = self.cursor_position 
        if direction == self.__class__.UP:
            self.cursor_position = (x,y-1)
        elif direction == self.__class__.DOWN:
            self.cursor_position = (x,y+1)
        elif direction == self.__class__.LEFT:
            self.cursor_position = (x-1,y)
        elif direction == self.__class__.RIGHT:
            self.cursor_position = (x+1,y)
            
        # Change cursor texture position
        x_offset = self.cell_width + self.cell_spacing
        y_offset = self.cell_height + self.cell_spacing
        
        # BELOW IS ANIMATION CODE FOR MENU CURSOR. SHOULD CURSOR BE ANIMATED?
#        if self.animate:            
#            # Finish previous animation before new
#            # FIXME: Doesn't seem to work as it should
#            if self.cursor_timeline is not None and self.cursor_timeline.is_playing():
#                self.cursor_timeline.pause()
#                self.cursor_timeline.advance(4)
#                
#            self.cursor_timeline = clutter.Timeline(4, 26)
#            alpha = clutter.Alpha(self.cursor_timeline, clutter.ramp_inc_func)
#            
#            x = self.cursor.get_x()
#            y = self.cursor.get_y()
#            if direction == self.__class__.UP:
#                behaviour = clutter.BehaviourPath(alpha, 
#                                                  ((x,y),
#                                                   (x,y - y_offset)))
#            elif direction == self.__class__.DOWN:
#                behaviour = clutter.BehaviourPath(alpha, 
#                                                  ((x,y),
#                                                   (x,y + y_offset)))
#            elif direction == self.__class__.RIGHT:
#                behaviour = clutter.BehaviourPath(alpha,
#                                                  ((x,y),
#                                                   (x + x_offset,y)))
#            elif direction == self.__class__.LEFT:
#                behaviour = clutter.BehaviourPath(alpha,
#                                                  ((x,y),
#                                                   (x - x_offset,y)))
#            behaviour.apply(self.cursor)
#            self.cursor_timeline.start()    
#        else:
        if direction == self.__class__.UP:
            self.cursor.move_by(0, -y_offset)
        elif direction == self.__class__.DOWN:
            self.cursor.move_by(0, y_offset)
        elif direction == self.__class__.LEFT:
            self.cursor.move_by(-x_offset, 0)
        elif direction == self.__class__.RIGHT:
            self.cursor.move_by(x_offset, 0)
    
    def _move_content(self, count, direction):
        """
        Move menuitems to given direction.
        @param count: How much we scroll (number of menuitems)
        @param direction: Which direction we scroll (See class constants)
        """
        # Change logical position 
        x,y = self.content_position 
        if direction == self.__class__.UP:
            self.content_position = (x,y+1)
        elif direction == self.__class__.DOWN:
            self.content_position = (x,y-1)
        elif direction == self.__class__.LEFT:
            self.content_position = (x+1,y)
        elif direction == self.__class__.RIGHT:
            self.content_position = (x-1,y)
        
        x_offset = count * (self.cell_width + self.cell_spacing)
        y_offset = count * (self.cell_height + self.cell_spacing)
#        if self.animate:
#            x = self.itemgroup.get_x()
#            y = self.itemgroup.get_y()
#            
#            # Finish previous animation before new
#            # FIXME: Doesn't seem to work as it should
#            if self.content_timeline is not None and self.content_timeline.is_playing():
#                self.content_timeline.pause()
#                self.content_timeline.advance(4)
#                
#            self.content_timeline = clutter.Timeline(4, 26)
#            alpha = clutter.Alpha(self.content_timeline, clutter.ramp_inc_func)
#            if direction == self.__class__.UP:
#                behaviour = clutter.BehaviourPath(alpha, 
#                                                  ((x,y),
#                                                   (x,y - y_offset)))
#            elif direction == self.__class__.DOWN:
#                behaviour = clutter.BehaviourPath(alpha, 
#                                                  ((x,y),
#                                                   (x,y + y_offset)))
#            elif direction == self.__class__.RIGHT:
#                behaviour = clutter.BehaviourPath(alpha,
#                                                  ((x,y),
#                                                   (x + x_offset,y)))
#            elif direction == self.__class__.LEFT:
#                behaviour = clutter.BehaviourPath(alpha,
#                                                  ((x,y),
#                                                   (x - x_offset,y)))
#            behaviour.apply(self.itemgroup)
#            self.content_timeline.start()
#        else:
        if direction == self.__class__.UP:
            self.itemgroup.move_by(0, -y_offset)
        elif direction == self.__class__.DOWN:
            self.itemgroup.move_by(0, y_offset)
        elif direction == self.__class__.RIGHT:
            self.itemgroup.move_by(x_offset, 0)
        elif direction == self.__class__.LEFT:
            self.itemgroup.move_by(-x_offset, 0)
    
    def _update_memory_state(self):
        """
        This method checks which menuitems are currently on screen area. Menuitems
        that are not on screen at the moment are freed from the memory. This is
        done by calling clutter.Actor.unrealize() -method. This way large menus
        consume same amount memory as smaller menus. This method is called every
        time after content (menuitems) is moved.
        """
        # DEBUG information
        #print "content position: " + str(self.content_position)
        #print "row count: " + str(self.row_count)
        #print "cell count: " + str(self.visible_cols)
        
        if self.optimize_memory:
            #TODO: Implement this method. Relize textures on screen and unrealize other textures
            pass
    
    def _scale_menuitem(self, menuitem):
        """
        Scale menuitem to fit into one cell. Preserves actor's aspect ratio.
        @param menuitem: clutter.Actor
        """
        w, h = menuitem.get_size()
        
        if w == self.cell_width and h == self.cell_height:
            return # Already correct size, no scale needed
        
        x_ratio = self.cell_width / float(w)
        y_ratio = self.cell_height / float(h)
        if x_ratio > y_ratio:
            menuitem.set_scale(self.cell_height /float(h), 
                               self.cell_height / float(h))
        else:
            menuitem.set_scale(self.cell_width / float(w), 
                               self.cell_width / float(w))
    
    def _scale_cursor(self):
        """
        Scale cursor. Override this method if you want to use cursor that is
        smaller or larger than menuitem. This default implementation scales
        cursor into the size of cell of the grid. Aspect ratio is not preserved!
        """
        x_ratio = self.cell_width / float(self.cursor.get_width())
        y_ratio = self.cell_height / float(self.cursor.get_height())
        self.cursor.set_scale(x_ratio, y_ratio)
    
    def _update_menuitem_position(self, menuitem, logical_position):
        """
        Recalculate menuitem's position.
        @param menuitem: Menuitem that is repositioned (clutter.Actor)
        @param logical_position: Logical position of the menuitem
        """
        if logical_position == 0:
            x = 0
            y = 0
        else:
            row = (logical_position / self.row_count) # Item's row
            col = logical_position % self.row_count   # Item's column
            if self.orientation == self.__class__.VERTICAL:
                x = col * (self.cell_width + self.cell_spacing)
                y = row * (self.cell_height + self.cell_spacing)
            elif self.orientation == self.__class__.HORIZONTAL:
                y = col * (self.cell_height + self.cell_spacing)
                x = row * (self.cell_width + self.cell_spacing)
        menuitem.set_position(x,y)
    
    def _update_selected_item(self, new_location, old_location=None):
        """
        This method allows to modify currently selected item. This can be
        overriden in inherited widgets.
        @param new_location: Logical location of current menuitem
        @param old_location: Logical location of last menuitem
        """
        pass
     
    def _animate_items_on_state_change(self):
        """
        This method is called when widget is set active or inactive. The method
        allows widget to react this action. Method should be overriden in
        inheriting classes. Default implementation doesn't do anything.
        """
        pass